feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441
feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441waleedlatif1 wants to merge 33 commits intostagingfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Extends MCP server CRUD to support Introduces a new encrypted per-(server,user) persistence layer ( Reviewed by Cursor Bugbot for commit 4f3b32a. Configure here. |
Greptile SummaryThis PR adds full OAuth 2.1 + PKCE support for outbound MCP servers, including Dynamic Client Registration, encrypted token persistence in a new
Confidence Score: 5/5Safe to merge; all previously identified blocking issues have been resolved and the OAuth flow is correctly guarded end-to-end. The core OAuth flow — PKCE generation, state CSRF protection, encrypted storage, per-user connection keying, and reauth surfacing — is implemented correctly. Previous rounds fixed token cleanup on credential change, per-server button scoping, HTTPS enforcement on the authorization URL, code verifier encryption, and stale-connected-status after token expiry. The remaining findings are UX edge cases and probe classification nits that do not affect the security or correctness of the happy path. No files require special attention; the new oauth/ layer and migration are self-contained and consistent with the existing encryption helpers. Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant UI as Settings UI
participant Start as /api/mcp/oauth/start
participant AS as Authorization Server
participant Callback as /api/mcp/oauth/callback
participant DB as mcp_server_oauth
participant Service as MCP Service
User->>UI: Click "Connect with OAuth"
UI->>Start: GET ?serverId=&workspaceId=
Start->>DB: getOrCreateOauthRow()
Start->>AS: mcpAuth() → DCR if needed
AS-->>Start: McpOauthRedirectRequired(authorizationUrl)
Start-->>UI: { status: redirect, authorizationUrl }
UI->>AS: window.open(authorizationUrl) [popup]
AS-->>Callback: GET ?code=&state= [redirect]
Callback->>DB: loadOauthRowByState(hash(state))
Callback->>DB: clearState()
Callback->>AS: mcpAuth(provider, authorizationCode)
AS-->>Callback: access_token + refresh_token
Callback->>DB: saveTokens() [encrypted]
Callback->>DB: clearVerifier()
Callback->>Service: discoverServerTools()
Callback-->>UI: postMessage mcp-oauth ok=true
UI->>UI: invalidateQueries + toast
Note over UI,Service: Tool execution path
User->>Service: executeTool()
Service->>DB: load tokens
Service->>AS: callTool() via SDK (auto-refresh on 401)
alt refresh fails
AS-->>Service: UnauthorizedError
Service-->>UI: reauth_required
end
Reviews (21): Last reviewed commit: "chore(mcp): final audit pass — strip res..." | Re-trigger Greptile |
|
Greptile summary findings addressed in f587e82:
The point about clearing a pre-registered Client ID by emptying the field is a follow-up — |
|
@greptile |
|
@cursor review |
|
@greptile |
|
@cursor review |
|
@greptile |
|
@cursor review |
|
@greptile |
|
@cursor review |
|
@cursor review |
|
@greptile |
…entProvider conformance - POST upsert now clears mcp_server_oauth rows when URL or client credentials change - Validate https: scheme on authorizationUrl before window.open to prevent javascript: URI execution - SimMcpOauthProvider now declares 'implements OAuthClientProvider' so SDK upgrades surface as compile errors - Edit form only sends oauthClientId when changed, mirroring oauthClientSecret behavior Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…k error Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move popup-opening into the mutation result so the caller can track its lifecycle. The 'Connecting…' spinner now stays until the user dismisses or completes the OAuth popup, preventing accidental double-clicks that would re-navigate the in-flight popup and invalidate state. Auto-OAuth after server creation now uses the same shared helper for consistent visual feedback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… on unmount - POST upsert: when reviving a soft-deleted server, drop any prior mcpServerOauth rows so stale tokens never silently carry over. - mcp.tsx: track the popup-closed setInterval per server in a ref and clear it on component unmount to avoid leaked timers. - client.ts: don't log OAuth-redirect/Unauthorized as connect errors; these are expected control flow during the auth bootstrap. - Use toError() for error message extraction. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The reference is only consumed by inline arrow handlers and is not observed by any memoized child or effect dep array, so useCallback adds overhead with no benefit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- useForceRefreshMcpTools: onSuccess → onSettled so cache reconciles on error - useMcpServerTest: replace `instanceof Error` ternaries with `toError().message` - mcp.tsx: use `--text-error` token (not the unused `--error`) and drop redundant dark variant Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- mcp.tsx: drop 8 useCallback wrappers (no React.memo'd children, no effect/memo deps observe them)
- mcp.tsx: drop filteredServers useMemo (cheap O(n) filter, no memoized consumers)
- mcp.tsx: serverToDelete {id, name} → serverToDeleteId; derive name from servers cache
- mcp-server-form-modal.tsx: drop 8 useCallback wrappers (same rationale)
- mcp-server-form-modal.tsx: drop hasChanges useMemo — deps change every keystroke so memo never caches
- mcp-server-form-modal.tsx: hover: → hover-hover: for codebase pointer:fine consistency
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…oken clear - client.ts: pass requestInit.headers for OAuth servers too. Previously OAuth authType set requestInit to undefined, dropping all custom headers including SIM_VIA_HEADER for cross-call loop prevention. The SDK's authProvider adds Authorization on top, so user/system headers must still flow through. - servers/[id]/route.ts: wrap server UPDATE and stale OAuth-token DELETE in a single transaction. Previously the update committed before the token clear, so a token-clear failure would leave new credentials with stale tokens. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…in edit modal Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Id on tool reauth errors Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- use mcp-oauth-${serverId} window target so concurrent OAuth flows on
different servers don't reuse and clobber the same popup
- drop redundant setQueryData before invalidate in useForceRefreshMcpTools
- replace hardcoded text-red-500 with text-[var(--text-error)] token
- normalize Plus icon to default h-[14px] w-[14px]
- drop useMemo on cheap toolsByServer/selectedServer derivations
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- hoist serverId from try-block const into outer scope so the catch's htmlClose carries it through to postMessage. Without it, parent's onMessage couldn't clear connectingOauthServers and the UI button stayed stuck on "Connecting…" until popup close. - relax https-only authorization URL check to permit http://localhost, http://127.0.0.1, and http://[::1] per OAuth 2.1 loopback exemption, unblocking local OAuth-protected MCP server development. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the old 0202_unknown_newton_destine migration which collided with staging's 0202/0203/0204. Bumps API validation route baseline to 735 to account for the two new MCP OAuth endpoints. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3f840d6 to
0de4158
Compare
…er bug - Prevent stored Authorization header from overwriting OAuth Bearer in McpClient - Per-user connection cache keying in connection-manager (token-leak prevention) - Tighten types in use-mcp-tools and tools/execute (drop `any`) - Replace raw <button> with emcn Button in mcp settings + form modal - Modal Cancel: variant='ghost' → 'default' to match design system - Derive editInitialData and showDeleteDialog from existing state - Replace refreshingServers Record + chained timer with mutation state - Trigger MCP OAuth start on create when authType==='oauth' from tool-input - Invalidate servers/storedTools alongside tools on force-refresh Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ments Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c45e164. Configure here.
…, callback escape - Extract useMcpOauthPopup hook so the tool-input "add server" flow gets the same postMessage/popup-poll lifecycle as the settings page. - PATCH /mcp/servers/:id now returns hasOauthClientSecret to mirror GET. - Escape '<' / '>' inside the JSON-stringified serverId emitted in the callback's <script> tag for defense-in-depth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |

Summary
OAuthClientProviderWWW-Authenticate/oauth-protected-resource)mcp_server_oauthtable; SDK refreshes automatically before expiry/api/mcp/oauth/start→/api/mcp/oauth/callback) withstateCSRF protectionreauth_requiredfrom tool execution when refresh token is invalid so the UI can prompt to reconnectType of Change
Testing
Tested manually against OAuth-protected MCP servers (Linear). Existing header-auth servers regression-checked.
Checklist